// app/projects/[projectId]/members/page.tsx 'use client'; import { use, useState, useEffect, useRef } from 'react'; import { Users, UserPlus, Crown, Shield, Eye, Edit2, Trash2, Mail, MoreVertical, Search, Filter, Check, ChevronsUpDown, Loader2, UserCog, Send } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { Label } from '@/components/ui/label'; import { useToast } from '@/hooks/use-toast'; import { cn } from '@/lib/utils'; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Separator } from '@/components/ui/separator'; import { getUsersForFilter } from '@/lib/gtc-contract/service'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Checkbox } from '@/components/ui/checkbox'; import { sendDataRoomInvitation, sendBulkDataRoomInvitations } from '@/components/project/dataroom-members'; interface Member { id: string; userId: number; user: { name: string; email: string; imageUrl?: string; domain: string; }; role: 'owner' | 'admin' | 'editor' | 'viewer'; addedAt: string; lastAccess?: string; } interface User { id: number; name: string; email: string; domain?: string; // 'partners' | 'internal' 등 } export default function ProjectMembersPage({ params: promiseParams }: { params: Promise<{ projectId: string }> }) { // Next.js 15+ params Promise 처리 const params = use(promiseParams); const projectId = params.projectId; const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [roleFilter, setRoleFilter] = useState('all'); const [addMemberOpen, setAddMemberOpen] = useState(false); const [editingMember, setEditingMember] = useState(null); // 프로젝트 정보 상태 추가 const [projectName, setProjectName] = useState('Data Room'); // 이메일 전송 관련 상태 const [sendEmailOnAdd, setSendEmailOnAdd] = useState(true); const [selectedMembers, setSelectedMembers] = useState>(new Set()); const [sendingEmail, setSendingEmail] = useState(false); // 사용자 선택 관련 상태 const [availableUsers, setAvailableUsers] = useState([]); const [selectedUser, setSelectedUser] = useState(null); const [userSearchTerm, setUserSearchTerm] = useState(''); const [userPopoverOpen, setUserPopoverOpen] = useState(false); const [loadingUsers, setLoadingUsers] = useState(false); const [isExternalUser, setIsExternalUser] = useState(false); // 외부 사용자 여부 const [newMemberRole, setNewMemberRole] = useState('viewer'); const [currentUserRole, setCurrentUserRole] = useState('viewer'); const [page, setPage] = useState(1); const pageSize = 20; // Command component key management const userOptionIdsRef = useRef>({}); const popoverContentId = `popover-content-${Date.now()}`; const commandId = `command-${Date.now()}`; const { toast } = useToast(); useEffect(() => { setPage(1); }, [searchQuery, roleFilter]); useEffect(() => { fetchMembers(); checkUserRole(); fetchProjectInfo(); }, [projectId]); // 프로젝트 정보 가져오기 const fetchProjectInfo = async () => { try { const response = await fetch(`/api/projects/${projectId}`); const data = await response.json(); if (data.name) { setProjectName(data.name); } } catch (error) { console.error('프로젝트 정보 로드 실패:', error); } }; // 다이얼로그가 열릴 때 사용자 목록 가져오기 useEffect(() => { if (addMemberOpen) { fetchAvailableUsers(); } else { // 다이얼로그가 닫힐 때 초기화 setSelectedUser(null); setUserSearchTerm(''); setNewMemberRole('viewer'); setIsExternalUser(false); setSendEmailOnAdd(true); } }, [addMemberOpen]); const fetchAvailableUsers = async () => { try { setLoadingUsers(true); const users = await getUsersForFilter(); // 이미 프로젝트에 있는 멤버는 제외 const memberUserIds = members.map(m => m.userId); const filteredUsers = users.filter(u => !memberUserIds.includes(u.id)); setAvailableUsers(filteredUsers); } catch (error) { console.error('사용자 목록 로드 실패:', error); toast({ title: '오류', description: '사용자 목록을 불러올 수 없습니다.', variant: 'destructive', }); } finally { setLoadingUsers(false); } }; const fetchMembers = async () => { try { setLoading(true); const response = await fetch(`/api/projects/${projectId}/members`); const data = await response.json(); setMembers(data.member); } catch (error) { toast({ title: '오류', description: '멤버 목록을 불러올 수 없습니다.', variant: 'destructive', }); } finally { setLoading(false); } }; const checkUserRole = async () => { try { const response = await fetch(`/api/projects/${projectId}/access`); const data = await response.json(); setCurrentUserRole(data.role); } catch (error) { console.error('권한 확인 실패:', error); } }; const addMember = async () => { if (!selectedUser) { toast({ title: '오류', description: '사용자를 선택해주세요.', variant: 'destructive', }); return; } try { const response = await fetch(`/api/projects/${projectId}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: selectedUser.id, role: newMemberRole, }), }); if (!response.ok) throw new Error('멤버 추가 실패'); // 이메일 전송 옵션이 켜져 있으면 이메일 발송 if (sendEmailOnAdd) { const emailResult = await sendDataRoomInvitation({ email: selectedUser.email, name: selectedUser.name, dataRoomName: projectName, role: newMemberRole, dataRoomUrl: `${window.location.origin}/projects/${projectId}` }); if (emailResult.success) { toast({ title: '성공', description: '멤버가 추가되고 초대 이메일이 발송되었습니다.', }); } else { toast({ title: '부분 성공', description: '멤버는 추가되었지만 이메일 발송에 실패했습니다.', variant: 'default', }); } } else { toast({ title: '성공', description: '새 멤버가 추가되었습니다.', }); } setAddMemberOpen(false); fetchMembers(); } catch (error) { toast({ title: '오류', description: '멤버 추가에 실패했습니다.', variant: 'destructive', }); } }; // 선택된 멤버들에게 이메일 보내기 const sendEmailToSelectedMembers = async () => { if (selectedMembers.size === 0) { toast({ title: '오류', description: '이메일을 보낼 멤버를 선택해주세요.', variant: 'destructive', }); return; } setSendingEmail(true); try { const membersToEmail = members .filter(m => selectedMembers.has(m.id)) .map(m => ({ email: m.user.email, name: m.user.name, dataRoomName: projectName, role: m.role, dataRoomUrl: `${window.location.origin}/projects/${projectId}` })); const result = await sendBulkDataRoomInvitations(membersToEmail); if (result.success) { toast({ title: '성공', description: result.message, }); setSelectedMembers(new Set()); } else { toast({ title: '오류', description: result.message, variant: 'destructive', }); } } catch (error) { toast({ title: '오류', description: '이메일 발송에 실패했습니다.', variant: 'destructive', }); } finally { setSendingEmail(false); } }; const updateMemberRole = async (memberId: string, newRole: string) => { try { const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: newRole }), }); if (!response.ok) throw new Error('역할 수정 실패'); toast({ title: '성공', description: '멤버 역할이 수정되었습니다.', }); fetchMembers(); setEditingMember(null); } catch (error) { toast({ title: '오류', description: '역할 수정에 실패했습니다.', variant: 'destructive', }); } }; const removeMember = async (memberId: string) => { if (!confirm('정말로 이 멤버를 제거하시겠습니까?')) return; try { const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, { method: 'DELETE', }); if (!response.ok) throw new Error('멤버 제거 실패'); toast({ title: '성공', description: '멤버가 제거되었습니다.', }); fetchMembers(); } catch (error) { toast({ title: '오류', description: '멤버 제거에 실패했습니다.', variant: 'destructive', }); } }; // 개별 멤버에게 이메일 보내기 const sendEmailToMember = async (member: Member) => { setSendingEmail(true); try { const result = await sendDataRoomInvitation({ email: member.user.email, name: member.user.name, dataRoomName: projectName, role: member.role, dataRoomUrl: `${window.location.origin}/projects/${projectId}` }); if (result.success) { toast({ title: '성공', description: '초대 이메일이 발송되었습니다.', }); } else { toast({ title: '오류', description: '이메일 발송에 실패했습니다.', variant: 'destructive', }); } } catch (error) { toast({ title: '오류', description: '이메일 발송에 실패했습니다.', variant: 'destructive', }); } finally { setSendingEmail(false); } }; const getRoleBadge = (role: string) => { const config = { owner: { icon: Crown, label: 'Owner', variant: 'default' as const, className: 'bg-purple-100 text-purple-800' }, admin: { icon: Shield, label: 'Admin', variant: 'secondary' as const, className: 'bg-blue-100 text-blue-800' }, editor: { icon: Edit2, label: 'Editor', variant: 'outline' as const, className: 'bg-green-100 text-green-800' }, viewer: { icon: Eye, label: 'Viewer', variant: 'outline' as const, className: '' } }; const { icon: Icon, label, variant, className } = config[role] || config.viewer; return ( {label} ); }; const filteredMembers = members .filter(m => { if (roleFilter !== 'all' && m.role !== roleFilter) return false; if (!searchQuery) return true; const query = searchQuery.toLowerCase(); return m.user.name.toLowerCase().includes(query) || m.user.email.toLowerCase().includes(query); }); const displayedMembers = filteredMembers.slice((page - 1) * pageSize, page * pageSize); const totalPages = Math.ceil(filteredMembers.length / pageSize); const canManageMembers = ['owner', 'admin'].includes(currentUserRole); // 사용자 필터링 로직 const filteredUsers = availableUsers.filter(user => { const searchLower = userSearchTerm.toLowerCase(); return user.name.toLowerCase().includes(searchLower) || user.email.toLowerCase().includes(searchLower); }); // 전체 선택/해제 const toggleSelectAll = () => { if (selectedMembers.size === displayedMembers.length) { setSelectedMembers(new Set()); } else { setSelectedMembers(new Set(displayedMembers.map(m => m.id))); } }; if (loading) { return (
); } return (
프로젝트 멤버 프로젝트에 참여 중인 멤버를 관리합니다 ({filteredMembers.length}명)
{selectedMembers.size > 0 && ( )} {canManageMembers && ( )}
{/* 필터 영역 */}
setSearchQuery(e.target.value)} className="pl-9" />
{/* 멤버 테이블 */}
0} onCheckedChange={toggleSelectAll} /> 멤버 역할 추가일 {/* 마지막 접속 */} {canManageMembers && 작업} {displayedMembers.map((member) => ( { const newSelected = new Set(selectedMembers); if (checked) { newSelected.add(member.id); } else { newSelected.delete(member.id); } setSelectedMembers(newSelected); }} />
{member.user.name.split(' ').map(n => n[0]).join('').toUpperCase()}
{member.user.name}
{member.user.email}
{getRoleBadge(member.role)} {new Date(member.addedAt).toLocaleDateString()} {/* {member.lastAccess ? new Date(member.lastAccess).toLocaleDateString() : '접속 기록 없음'} */} {canManageMembers && ( sendEmailToMember(member)} disabled={sendingEmail} > 이메일 보내기 {member.role !== 'owner' && ( <> setEditingMember(member)}> 역할 변경 removeMember(member.id)} className="text-red-600" > 멤버 제거 )} )}
))} {displayedMembers.length === 0 && (
{searchQuery || roleFilter !== 'all' ? '검색 결과가 없습니다.' : '아직 멤버가 없습니다.'}
)}
{/* 페이지네이션 */} {totalPages > 1 && (

전체 {filteredMembers.length}명 중 {(page - 1) * pageSize + 1}- {Math.min(page * pageSize, filteredMembers.length)}명 표시

{page} / {totalPages}
)}
{/* 역할 변경 다이얼로그 */} !open && setEditingMember(null)}> 역할 변경 {editingMember?.user.name}님의 역할을 변경합니다.
{/* 멤버 추가 다이얼로그 */} 새 멤버 추가 프로젝트에 새 멤버를 추가합니다. 사용자 유형에 따라 부여 가능한 권한이 다릅니다. 내부 사용자 외부 사용자 (파트너)
{loadingUsers ? (
사용자 목록 불러오는 중...
) : ( <> { e.stopPropagation(); const target = e.currentTarget; target.scrollTop += e.deltaY; }} > 사용자를 찾을 수 없습니다. {filteredUsers .filter(u => u.domain !== 'partners') .map((user) => ( { setSelectedUser(user); setUserPopoverOpen(false); setIsExternalUser(false); setNewMemberRole('viewer'); }} value={`${user.name} ${user.email}`} className="truncate" >
{user.name}
{user.email}
))}

내부 사용자는 모든 역할을 부여할 수 있습니다.

)}

보안 정책 안내
외부 사용자(파트너)는 보안 정책상 Viewer 권한만 부여 가능합니다.

{loadingUsers ? (
사용자 목록 불러오는 중...
) : ( { e.stopPropagation(); const target = e.currentTarget; target.scrollTop += e.deltaY; }} > 파트너를 찾을 수 없습니다. {filteredUsers .filter(u => u.domain === 'partners') .map((user) => ( { setSelectedUser(user); setUserPopoverOpen(false); setIsExternalUser(true); setNewMemberRole('viewer'); }} value={`${user.name} ${user.email}`} className="truncate" >
{user.name}
{user.email}
))}
)}
{/* 이메일 전송 옵션 */}
setSendEmailOnAdd(checked as boolean)} />
); }